D:\a\cssh-rs\cssh-rs\xtask\src\release.rs
Line | Count | Source |
1 | | //! Release preparation and git tag creation. |
2 | | //! |
3 | | //! [`prepare_release`] bumps the version, optionally creates a maintenance |
4 | | //! branch, updates `Cargo.toml` and `Cargo.lock`, generates the changelog, |
5 | | //! commits, and pushes. |
6 | | //! |
7 | | //! [`create_release_tag`] validates the current state and creates an annotated |
8 | | //! git tag that triggers the GitHub Actions release workflow. |
9 | | |
10 | | use anyhow::{bail, Context, Result}; |
11 | | use semver::Version; |
12 | | |
13 | | /// Type of version increment for a release. |
14 | | #[derive(Debug, PartialEq)] |
15 | | pub enum ReleaseType { |
16 | | /// Increment the major component (X.0.0). |
17 | | Major, |
18 | | /// Increment the minor component (0.X.0). |
19 | | Minor, |
20 | | /// Increment the patch component (0.0.X). |
21 | | Patch, |
22 | | } |
23 | | |
24 | | impl std::fmt::Display for ReleaseType { |
25 | 18 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
26 | 18 | match self { |
27 | 0 | ReleaseType::Major => write!(f, "major"), |
28 | 15 | ReleaseType::Minor => write!(f, "minor"), |
29 | 3 | ReleaseType::Patch => write!(f, "patch"), |
30 | | } |
31 | 18 | } |
32 | | } |
33 | | |
34 | | /// All side-effecting operations required by this module. |
35 | | /// |
36 | | /// Each method maps to exactly one external operation, making every step |
37 | | /// independently mockable in tests. |
38 | | pub trait ReleaseSystem { |
39 | | /// Run `git status --porcelain` and return its stdout. |
40 | | /// |
41 | | /// # Errors |
42 | | /// |
43 | | /// Returns an error if the process fails. |
44 | | fn git_status_porcelain(&self) -> Result<String>; |
45 | | |
46 | | /// Return the current git branch name. |
47 | | /// |
48 | | /// # Errors |
49 | | /// |
50 | | /// Returns an error if the process fails. |
51 | | fn git_current_branch(&self) -> Result<String>; |
52 | | |
53 | | /// Create and switch to a new branch with `git checkout -b <name>`. |
54 | | /// |
55 | | /// # Arguments |
56 | | /// |
57 | | /// * `name` - Branch name to create. |
58 | | /// |
59 | | /// # Errors |
60 | | /// |
61 | | /// Returns an error if the process fails. |
62 | | fn git_checkout_new_branch(&self, name: &str) -> Result<()>; |
63 | | |
64 | | /// Switch to an existing branch with `git checkout <name>`. |
65 | | /// |
66 | | /// Relies on git's DWIM behaviour: when `<name>` exists only as |
67 | | /// `refs/remotes/origin/<name>`, a local tracking branch is created. |
68 | | /// The caller is responsible for fetching beforehand. |
69 | | /// |
70 | | /// # Arguments |
71 | | /// |
72 | | /// * `name` - Branch name to switch to. |
73 | | /// |
74 | | /// # Errors |
75 | | /// |
76 | | /// Returns an error if the process fails. |
77 | | fn git_checkout(&self, name: &str) -> Result<()>; |
78 | | |
79 | | /// Return `true` when `refs/heads/<name>` exists locally. |
80 | | /// |
81 | | /// # Arguments |
82 | | /// |
83 | | /// * `name` - Local branch name to look up. |
84 | | /// |
85 | | /// # Errors |
86 | | /// |
87 | | /// Returns an error if the process fails for a reason other than the ref |
88 | | /// not existing. |
89 | | fn git_branch_exists_local(&self, name: &str) -> Result<bool>; |
90 | | |
91 | | /// Return `true` when `refs/remotes/origin/<name>` exists locally. |
92 | | /// |
93 | | /// The remote ref is only present after a successful `git fetch`, so |
94 | | /// callers must fetch before relying on this answer to reflect the |
95 | | /// remote's actual state. |
96 | | /// |
97 | | /// # Arguments |
98 | | /// |
99 | | /// * `name` - Branch name to look up under `origin/`. |
100 | | /// |
101 | | /// # Errors |
102 | | /// |
103 | | /// Returns an error if the process fails for a reason other than the ref |
104 | | /// not existing. |
105 | | fn git_branch_exists_origin(&self, name: &str) -> Result<bool>; |
106 | | |
107 | | /// Stage the given files with `git add`. |
108 | | /// |
109 | | /// # Arguments |
110 | | /// |
111 | | /// * `files` - Paths to stage. |
112 | | /// |
113 | | /// # Errors |
114 | | /// |
115 | | /// Returns an error if the process fails. |
116 | | fn git_add(&self, files: &[String]) -> Result<()>; |
117 | | |
118 | | /// Commit staged changes with the given message. |
119 | | /// |
120 | | /// # Arguments |
121 | | /// |
122 | | /// * `message` - Commit message. |
123 | | /// * `no_verify` - When `true`, pass `--no-verify` to bypass git hooks. |
124 | | /// |
125 | | /// # Errors |
126 | | /// |
127 | | /// Returns an error if the process fails. |
128 | | fn git_commit(&self, message: &str, no_verify: bool) -> Result<()>; |
129 | | |
130 | | /// Run `git push` with the given extra arguments. |
131 | | /// |
132 | | /// # Arguments |
133 | | /// |
134 | | /// * `args` - Extra arguments appended to `git push`. |
135 | | /// |
136 | | /// # Errors |
137 | | /// |
138 | | /// Returns an error if the process fails. |
139 | | fn git_push(&self, args: &[String]) -> Result<()>; |
140 | | |
141 | | /// Open a GitHub pull request against `base` using `gh pr create --fill`. |
142 | | /// |
143 | | /// `--fill` derives title and body from the latest commit, so the caller |
144 | | /// must ensure that commit has the desired subject/body. |
145 | | /// |
146 | | /// # Arguments |
147 | | /// |
148 | | /// * `base` - Branch the PR targets. |
149 | | /// |
150 | | /// # Errors |
151 | | /// |
152 | | /// Returns an error if the process fails. |
153 | | fn gh_pr_create(&self, base: &str) -> Result<()>; |
154 | | |
155 | | /// Return `git tag -l <tag>` stdout for the given tag name. |
156 | | /// |
157 | | /// # Arguments |
158 | | /// |
159 | | /// * `tag` - Tag name to check. |
160 | | /// |
161 | | /// # Errors |
162 | | /// |
163 | | /// Returns an error if the process fails. |
164 | | fn git_tag_list(&self, tag: &str) -> Result<String>; |
165 | | |
166 | | /// Return the subject of the latest commit (`git log -1 --pretty=format:%s`). |
167 | | /// |
168 | | /// # Errors |
169 | | /// |
170 | | /// Returns an error if the process fails. |
171 | | fn git_log_latest_subject(&self) -> Result<String>; |
172 | | |
173 | | /// Run `git fetch`. |
174 | | /// |
175 | | /// # Errors |
176 | | /// |
177 | | /// Returns an error if the process fails (non-fatal; callers may continue). |
178 | | fn git_fetch(&self) -> Result<()>; |
179 | | |
180 | | /// Return the number of commits the local branch is behind `<branch>` on |
181 | | /// the remote. |
182 | | /// |
183 | | /// # Arguments |
184 | | /// |
185 | | /// * `branch` - Remote branch to compare against. |
186 | | /// |
187 | | /// # Errors |
188 | | /// |
189 | | /// Returns an error if the process fails. |
190 | | fn git_rev_list_count_behind(&self, branch: &str) -> Result<u32>; |
191 | | |
192 | | /// Return the number of commits the local branch is ahead of `<branch>` |
193 | | /// on the remote. |
194 | | /// |
195 | | /// # Arguments |
196 | | /// |
197 | | /// * `branch` - Remote branch to compare against. |
198 | | /// |
199 | | /// # Errors |
200 | | /// |
201 | | /// Returns an error if the process fails. |
202 | | fn git_rev_list_count_ahead(&self, branch: &str) -> Result<u32>; |
203 | | |
204 | | /// Create an annotated git tag. |
205 | | /// |
206 | | /// # Arguments |
207 | | /// |
208 | | /// * `tag` - Tag name. |
209 | | /// * `message` - Annotation message. |
210 | | /// |
211 | | /// # Errors |
212 | | /// |
213 | | /// Returns an error if the process fails. |
214 | | fn git_create_annotated_tag(&self, tag: &str, message: &str) -> Result<()>; |
215 | | |
216 | | /// Push a tag to `origin`. |
217 | | /// |
218 | | /// # Arguments |
219 | | /// |
220 | | /// * `tag` - Tag name to push. |
221 | | /// |
222 | | /// # Errors |
223 | | /// |
224 | | /// Returns an error if the process fails. |
225 | | fn git_push_tag(&self, tag: &str) -> Result<()>; |
226 | | |
227 | | /// Read the contents of `Cargo.toml`. |
228 | | /// |
229 | | /// # Errors |
230 | | /// |
231 | | /// Returns an error if the file cannot be read. |
232 | | fn read_cargo_toml(&self) -> Result<String>; |
233 | | |
234 | | /// Write `content` to `Cargo.toml`. |
235 | | /// |
236 | | /// # Errors |
237 | | /// |
238 | | /// Returns an error if the write fails. |
239 | | fn write_cargo_toml(&self, content: &str) -> Result<()>; |
240 | | |
241 | | /// Run `cargo update --workspace` to refresh `Cargo.lock`. |
242 | | /// |
243 | | /// # Errors |
244 | | /// |
245 | | /// Returns an error if the process fails. |
246 | | fn cargo_update_workspace(&self) -> Result<()>; |
247 | | |
248 | | /// Generate the changelog for the current version. |
249 | | /// |
250 | | /// # Errors |
251 | | /// |
252 | | /// Returns an error if changelog generation fails. |
253 | | fn generate_changelog(&self) -> Result<()>; |
254 | | |
255 | | /// Display `message` and read a line of user input. |
256 | | /// |
257 | | /// # Arguments |
258 | | /// |
259 | | /// * `message` - Prompt text. |
260 | | /// |
261 | | /// # Returns |
262 | | /// |
263 | | /// The trimmed response string. |
264 | | /// |
265 | | /// # Errors |
266 | | /// |
267 | | /// Returns an error if stdin cannot be read. |
268 | | fn prompt_user(&self, message: &str) -> Result<String>; |
269 | | } |
270 | | |
271 | | /// Check whether `ref_name` exists via `git show-ref --verify`. |
272 | | /// |
273 | | /// `git show-ref` is documented to exit 0 when the ref exists, 1 when it does |
274 | | /// not, and other non-zero codes for actual failures (bad arguments, broken |
275 | | /// repo, etc.). Mapping every non-zero exit to "missing" would silently |
276 | | /// swallow real errors, so the latter must surface as an `Err`. |
277 | | #[cfg_attr(coverage_nightly, coverage(off))] |
278 | | fn show_ref_exists(ref_name: &str) -> Result<bool> { |
279 | | let output = std::process::Command::new("git") |
280 | | .args(["show-ref", "--verify", "--quiet", ref_name]) |
281 | | .output() |
282 | | .context("failed to run `git show-ref`")?; |
283 | | match output.status.code() { |
284 | | Some(0) => Ok(true), |
285 | | Some(1) => Ok(false), |
286 | | _ => bail!( |
287 | | "`git show-ref --verify {ref_name}` failed with status {}: {}", |
288 | | output.status, |
289 | | String::from_utf8_lossy(&output.stderr).trim(), |
290 | | ), |
291 | | } |
292 | | } |
293 | | |
294 | | /// Run `git rev-list --count <range>` and return the parsed commit count. |
295 | | /// |
296 | | /// A non-zero exit (e.g. unknown ref) or unparseable stdout must surface as an |
297 | | /// `Err` - returning `0` would silently mask "stale ref" or "git failed" as |
298 | | /// "branch is up to date". |
299 | | #[cfg_attr(coverage_nightly, coverage(off))] |
300 | | fn rev_list_count(range: &str) -> Result<u32> { |
301 | | let output = std::process::Command::new("git") |
302 | | .args(["rev-list", "--count", range]) |
303 | | .output() |
304 | | .context("failed to run `git rev-list`")?; |
305 | | if !output.status.success() { |
306 | | bail!( |
307 | | "`git rev-list --count {range}` failed with status {}: {}", |
308 | | output.status, |
309 | | String::from_utf8_lossy(&output.stderr).trim(), |
310 | | ); |
311 | | } |
312 | | let stdout = String::from_utf8_lossy(&output.stdout); |
313 | | stdout.trim().parse::<u32>().with_context(|| { |
314 | | format!("failed to parse `git rev-list --count {range}` stdout: {stdout:?}") |
315 | | }) |
316 | | } |
317 | | |
318 | | /// Production implementation of [`ReleaseSystem`]. |
319 | | pub struct RealSystem; |
320 | | |
321 | | #[cfg_attr(coverage_nightly, coverage(off))] |
322 | | impl ReleaseSystem for RealSystem { |
323 | | fn git_status_porcelain(&self) -> Result<String> { |
324 | | let output = std::process::Command::new("git") |
325 | | .args(["status", "--porcelain"]) |
326 | | .output() |
327 | | .context("failed to run `git status --porcelain`")?; |
328 | | Ok(String::from_utf8_lossy(&output.stdout).into_owned()) |
329 | | } |
330 | | |
331 | | fn git_current_branch(&self) -> Result<String> { |
332 | | let output = std::process::Command::new("git") |
333 | | .args(["branch", "--show-current"]) |
334 | | .output() |
335 | | .context("failed to run `git branch --show-current`")?; |
336 | | Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) |
337 | | } |
338 | | |
339 | | fn git_checkout_new_branch(&self, name: &str) -> Result<()> { |
340 | | let status = std::process::Command::new("git") |
341 | | .args(["checkout", "-b", name]) |
342 | | .status() |
343 | | .context("failed to run `git checkout -b`")?; |
344 | | if !status.success() { |
345 | | bail!("`git checkout -b {name}` failed with status {status}"); |
346 | | } |
347 | | Ok(()) |
348 | | } |
349 | | |
350 | | fn git_checkout(&self, name: &str) -> Result<()> { |
351 | | let status = std::process::Command::new("git") |
352 | | .args(["checkout", name]) |
353 | | .status() |
354 | | .context("failed to run `git checkout`")?; |
355 | | if !status.success() { |
356 | | bail!("`git checkout {name}` failed with status {status}"); |
357 | | } |
358 | | Ok(()) |
359 | | } |
360 | | |
361 | | fn git_branch_exists_local(&self, name: &str) -> Result<bool> { |
362 | | show_ref_exists(&format!("refs/heads/{name}")) |
363 | | } |
364 | | |
365 | | fn git_branch_exists_origin(&self, name: &str) -> Result<bool> { |
366 | | show_ref_exists(&format!("refs/remotes/origin/{name}")) |
367 | | } |
368 | | |
369 | | fn git_add(&self, files: &[String]) -> Result<()> { |
370 | | let status = std::process::Command::new("git") |
371 | | .arg("add") |
372 | | .args(files) |
373 | | .status() |
374 | | .context("failed to run `git add`")?; |
375 | | if !status.success() { |
376 | | bail!("`git add` failed with status {status}"); |
377 | | } |
378 | | Ok(()) |
379 | | } |
380 | | |
381 | | fn git_commit(&self, message: &str, no_verify: bool) -> Result<()> { |
382 | | let mut cmd = std::process::Command::new("git"); |
383 | | cmd.args(["commit", "-m", message]); |
384 | | if no_verify { |
385 | | cmd.arg("--no-verify"); |
386 | | } |
387 | | let status = cmd.status().context("failed to run `git commit`")?; |
388 | | if !status.success() { |
389 | | bail!("`git commit` failed with status {status}"); |
390 | | } |
391 | | Ok(()) |
392 | | } |
393 | | |
394 | | fn git_push(&self, args: &[String]) -> Result<()> { |
395 | | let status = std::process::Command::new("git") |
396 | | .arg("push") |
397 | | .args(args) |
398 | | .status() |
399 | | .context("failed to run `git push`")?; |
400 | | if !status.success() { |
401 | | bail!("`git push` failed with status {status}"); |
402 | | } |
403 | | Ok(()) |
404 | | } |
405 | | |
406 | | fn gh_pr_create(&self, base: &str) -> Result<()> { |
407 | | let status = std::process::Command::new("gh") |
408 | | .args(["pr", "create", "--base", base, "--fill"]) |
409 | | .status() |
410 | | .context("failed to run `gh pr create`")?; |
411 | | if !status.success() { |
412 | | bail!("`gh pr create --base {base}` failed with status {status}"); |
413 | | } |
414 | | Ok(()) |
415 | | } |
416 | | |
417 | | fn git_tag_list(&self, tag: &str) -> Result<String> { |
418 | | let output = std::process::Command::new("git") |
419 | | .args(["tag", "-l", tag]) |
420 | | .output() |
421 | | .context("failed to run `git tag -l`")?; |
422 | | Ok(String::from_utf8_lossy(&output.stdout).into_owned()) |
423 | | } |
424 | | |
425 | | fn git_log_latest_subject(&self) -> Result<String> { |
426 | | let output = std::process::Command::new("git") |
427 | | .args(["log", "-1", "--pretty=format:%s"]) |
428 | | .output() |
429 | | .context("failed to run `git log`")?; |
430 | | Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) |
431 | | } |
432 | | |
433 | | fn git_fetch(&self) -> Result<()> { |
434 | | let status = std::process::Command::new("git") |
435 | | .arg("fetch") |
436 | | .status() |
437 | | .context("failed to run `git fetch`")?; |
438 | | if !status.success() { |
439 | | bail!("`git fetch` failed with status {status}"); |
440 | | } |
441 | | Ok(()) |
442 | | } |
443 | | |
444 | | fn git_rev_list_count_behind(&self, branch: &str) -> Result<u32> { |
445 | | rev_list_count(&format!("HEAD..origin/{branch}")) |
446 | | } |
447 | | |
448 | | fn git_rev_list_count_ahead(&self, branch: &str) -> Result<u32> { |
449 | | rev_list_count(&format!("origin/{branch}..HEAD")) |
450 | | } |
451 | | |
452 | | fn git_create_annotated_tag(&self, tag: &str, message: &str) -> Result<()> { |
453 | | let status = std::process::Command::new("git") |
454 | | .args(["tag", "-a", tag, "-m", message]) |
455 | | .status() |
456 | | .context("failed to run `git tag -a`")?; |
457 | | if !status.success() { |
458 | | bail!("`git tag -a {tag}` failed with status {status}"); |
459 | | } |
460 | | Ok(()) |
461 | | } |
462 | | |
463 | | fn git_push_tag(&self, tag: &str) -> Result<()> { |
464 | | let status = std::process::Command::new("git") |
465 | | .args(["push", "origin", tag]) |
466 | | .status() |
467 | | .context("failed to run `git push origin <tag>`")?; |
468 | | if !status.success() { |
469 | | bail!("`git push origin {tag}` failed with status {status}"); |
470 | | } |
471 | | Ok(()) |
472 | | } |
473 | | |
474 | | fn read_cargo_toml(&self) -> Result<String> { |
475 | | std::fs::read_to_string("Cargo.toml").context("failed to read Cargo.toml") |
476 | | } |
477 | | |
478 | | fn write_cargo_toml(&self, content: &str) -> Result<()> { |
479 | | std::fs::write("Cargo.toml", content).context("failed to write Cargo.toml") |
480 | | } |
481 | | |
482 | | fn cargo_update_workspace(&self) -> Result<()> { |
483 | | let status = std::process::Command::new("cargo") |
484 | | .args(["update", "--workspace"]) |
485 | | .status() |
486 | | .context("failed to run `cargo update --workspace`")?; |
487 | | if !status.success() { |
488 | | bail!("`cargo update --workspace` failed with status {status}"); |
489 | | } |
490 | | Ok(()) |
491 | | } |
492 | | |
493 | | fn generate_changelog(&self) -> Result<()> { |
494 | | crate::changelog::generate_changelog(&crate::changelog::RealSystem) |
495 | | } |
496 | | |
497 | | fn prompt_user(&self, message: &str) -> Result<String> { |
498 | | use std::io::Write; |
499 | | // Interactive prompt: written directly to stdout so it lacks the |
500 | | // timestamp/level prefix the logger would add, and flushed so the |
501 | | // cursor sits next to the prompt before stdin is read. |
502 | | print!("{message}"); |
503 | | std::io::stdout() |
504 | | .flush() |
505 | | .context("failed to flush stdout")?; |
506 | | let mut input = String::new(); |
507 | | std::io::stdin() |
508 | | .read_line(&mut input) |
509 | | .context("failed to read user input")?; |
510 | | Ok(input.trim().to_owned()) |
511 | | } |
512 | | } |
513 | | |
514 | | /// Determine the suggested next version and release type from the current branch. |
515 | | /// |
516 | | /// `main` -> minor bump; `*-maintenance` -> patch bump. |
517 | | /// |
518 | | /// # Arguments |
519 | | /// |
520 | | /// * `current` - Current version from `Cargo.toml`. |
521 | | /// * `branch` - Current git branch name. |
522 | | /// |
523 | | /// # Returns |
524 | | /// |
525 | | /// `(ReleaseType, next_version)`. |
526 | | /// |
527 | | /// # Errors |
528 | | /// |
529 | | /// Returns an error when `branch` is neither `main` nor ends with |
530 | | /// `-maintenance`. |
531 | 13 | pub fn suggest_next_version(current: &Version, branch: &str) -> Result<(ReleaseType, Version)> { |
532 | 13 | if branch == "main" { |
533 | 9 | let mut next = current.clone(); |
534 | 9 | next.minor += 1; |
535 | 9 | next.patch = 0; |
536 | 9 | Ok((ReleaseType::Minor, next)) |
537 | 4 | } else if branch.ends_with("-maintenance") { |
538 | 2 | let mut next = current.clone(); |
539 | 2 | next.patch += 1; |
540 | 2 | Ok((ReleaseType::Patch, next)) |
541 | | } else { |
542 | 2 | bail!( |
543 | | "must be on 'main' or a '*-maintenance' branch to prepare a release \ |
544 | | (current branch: {branch})" |
545 | | ) |
546 | | } |
547 | 13 | } |
548 | | |
549 | | /// Determine the release type by comparing two versions. |
550 | | /// |
551 | | /// # Arguments |
552 | | /// |
553 | | /// * `current` - The version before the release. |
554 | | /// * `next` - The version after the release. |
555 | | /// |
556 | | /// # Returns |
557 | | /// |
558 | | /// The most significant component that changed. |
559 | 4 | pub fn determine_release_type(current: &Version, next: &Version) -> ReleaseType { |
560 | 4 | if next.major > current.major { |
561 | 1 | ReleaseType::Major |
562 | 3 | } else if next.minor > current.minor { |
563 | 1 | ReleaseType::Minor |
564 | | } else { |
565 | 2 | ReleaseType::Patch |
566 | | } |
567 | 4 | } |
568 | | |
569 | | /// Rewrite the `[workspace.package].version` field in a `Cargo.toml` string. |
570 | | /// |
571 | | /// Uses `toml_edit` to preserve all existing formatting. |
572 | | /// |
573 | | /// # Arguments |
574 | | /// |
575 | | /// * `cargo_toml_content` - Raw TOML text of `Cargo.toml`. |
576 | | /// * `new_version` - Version string to set. |
577 | | /// |
578 | | /// # Returns |
579 | | /// |
580 | | /// Updated TOML text. |
581 | | /// |
582 | | /// # Errors |
583 | | /// |
584 | | /// Returns an error if `cargo_toml_content` cannot be parsed as TOML or the |
585 | | /// `[workspace.package].version` key is absent. |
586 | 9 | pub fn set_cargo_toml_version(cargo_toml_content: &str, new_version: &str) -> Result<String> { |
587 | 9 | let mut doc: toml_edit::DocumentMut = cargo_toml_content |
588 | 9 | .parse() |
589 | 9 | .context("failed to parse Cargo.toml")?0 ; |
590 | 9 | let version8 = doc |
591 | 9 | .get_mut("workspace") |
592 | 9 | .and_then(|w| w.as_table_mut()) |
593 | 9 | .and_then(|t| t.get_mut("package")) |
594 | 9 | .and_then(|p| p8 .as_table_mut8 ()) |
595 | 9 | .and_then(|t| t8 .get_mut8 ("version"8 )) |
596 | 9 | .context("missing [workspace.package].version in Cargo.toml")?1 ; |
597 | 8 | *version = toml_edit::value(new_version); |
598 | 8 | Ok(doc.to_string()) |
599 | 9 | } |
600 | | |
601 | | /// Ensure the maintenance branch exists and is checked out before any release |
602 | | /// prepared from `main` (major/minor create + push the maintenance branch and |
603 | | /// then branch off `release-X.Y.Z`; a custom patch version typed on `main` |
604 | | /// switches to an existing maintenance branch and pushes the version bump |
605 | | /// directly). |
606 | | /// |
607 | | /// Fetches once, then handles the four |
608 | | /// (local exists, origin exists) combinations: |
609 | | /// |
610 | | /// - `(false, false)`: create the branch from the current HEAD (`main`) and |
611 | | /// push it to `origin`. |
612 | | /// - `(true, false)`: switch to the existing local branch and push it. |
613 | | /// - `(false, true)`: switch to the branch - git's DWIM creates a local |
614 | | /// tracking branch from `origin/<name>`. |
615 | | /// - `(true, true)`: switch to the local branch and verify it is neither |
616 | | /// behind nor ahead of `origin`. |
617 | | /// |
618 | | /// A failed `git fetch` is fatal here: every subsequent decision depends on |
619 | | /// `refs/remotes/origin/*` reflecting the remote's actual state, and a stale |
620 | | /// view can cause the wrong branch (create / push / checkout / fail-behind) |
621 | | /// to be taken. |
622 | | /// |
623 | | /// # Arguments |
624 | | /// |
625 | | /// * `system` - Injected I/O provider. |
626 | | /// * `maintenance_branch` - Name of the maintenance branch to ready. |
627 | | /// |
628 | | /// # Errors |
629 | | /// |
630 | | /// Returns an error if any git step fails or the local branch is behind or |
631 | | /// ahead of origin. |
632 | 8 | fn ensure_maintenance_branch_ready<S: ReleaseSystem>( |
633 | 8 | system: &S, |
634 | 8 | maintenance_branch: &str, |
635 | 8 | ) -> Result<()> { |
636 | 8 | log::info!("Fetching origin to check maintenance branch state"); |
637 | 8 | system |
638 | 8 | .git_fetch() |
639 | 8 | .context("failed to fetch from origin - cannot determine maintenance branch state")?1 ; |
640 | | |
641 | 7 | let local_exists = system.git_branch_exists_local(maintenance_branch)?0 ; |
642 | 7 | let origin_exists = system.git_branch_exists_origin(maintenance_branch)?0 ; |
643 | | |
644 | 7 | match (local_exists, origin_exists) { |
645 | | (false, false) => { |
646 | 1 | log::info!( |
647 | | "Maintenance branch {maintenance_branch} does not exist; \ |
648 | | creating from current HEAD and pushing to origin" |
649 | | ); |
650 | 1 | system.git_checkout_new_branch(maintenance_branch)?0 ; |
651 | 1 | system.git_push(&[ |
652 | 1 | "-u".to_owned(), |
653 | 1 | "origin".to_owned(), |
654 | 1 | maintenance_branch.to_owned(), |
655 | 1 | ])?0 ; |
656 | | } |
657 | | (true, false) => { |
658 | 1 | log::info!( |
659 | | "Maintenance branch {maintenance_branch} exists locally only; \ |
660 | | switching to it and pushing to origin" |
661 | | ); |
662 | 1 | system.git_checkout(maintenance_branch)?0 ; |
663 | 1 | system.git_push(&[ |
664 | 1 | "-u".to_owned(), |
665 | 1 | "origin".to_owned(), |
666 | 1 | maintenance_branch.to_owned(), |
667 | 1 | ])?0 ; |
668 | | } |
669 | | (false, true) => { |
670 | 1 | log::info!( |
671 | | "Maintenance branch {maintenance_branch} exists on origin only; \ |
672 | | creating a local tracking branch" |
673 | | ); |
674 | 1 | system.git_checkout(maintenance_branch)?0 ; |
675 | | } |
676 | | (true, true) => { |
677 | 4 | log::info!( |
678 | | "Maintenance branch {maintenance_branch} exists locally and on \ |
679 | | origin; switching to local branch" |
680 | | ); |
681 | 4 | system.git_checkout(maintenance_branch)?0 ; |
682 | 4 | let behind = system.git_rev_list_count_behind(maintenance_branch)?0 ; |
683 | 4 | if behind > 0 { |
684 | 1 | bail!( |
685 | | "local maintenance branch {maintenance_branch} is {behind} commit(s) \ |
686 | | behind origin - run `git pull` first" |
687 | | ); |
688 | 3 | } |
689 | | // Unpushed local commits would otherwise leak into the release PR |
690 | | // when we branch off `release-X.Y.Z` from here. |
691 | 3 | let ahead = system.git_rev_list_count_ahead(maintenance_branch)?0 ; |
692 | 3 | if ahead > 0 { |
693 | 1 | bail!( |
694 | | "local maintenance branch {maintenance_branch} is {ahead} commit(s) \ |
695 | | ahead of origin - push it before preparing a release" |
696 | | ); |
697 | 2 | } |
698 | | } |
699 | | } |
700 | 5 | Ok(()) |
701 | 8 | } |
702 | | |
703 | | /// Prepare a new release. |
704 | | /// |
705 | | /// Full workflow: |
706 | | /// 1. Verify working tree is clean. |
707 | | /// 2. Detect branch and suggest release type / next version. |
708 | | /// 3. Prompt user (accepts custom version input). |
709 | | /// 4. When releasing from `main`: ensure the target maintenance branch is |
710 | | /// ready (see [`ensure_maintenance_branch_ready`]). For a major/minor |
711 | | /// release this creates the branch when missing; for a patch release |
712 | | /// entered as a custom version it switches to the existing branch. |
713 | | /// Then, for a major/minor release, branch off a `release-X.Y.Z` branch |
714 | | /// for the version bump. For a patch release on a maintenance branch |
715 | | /// (or switched to one above), stay on that branch. |
716 | | /// 5. Update `Cargo.toml` version. |
717 | | /// 6. Run `cargo update --workspace`. |
718 | | /// 7. Generate changelog. |
719 | | /// 8. Commit the version bump. |
720 | | /// 9. For a major/minor release from `main`: push the `release-X.Y.Z` |
721 | | /// branch and open a GH PR against the maintenance branch. For a patch |
722 | | /// release: push directly to the maintenance branch. |
723 | | /// |
724 | | /// # Arguments |
725 | | /// |
726 | | /// * `system` - Injected I/O provider. |
727 | | /// |
728 | | /// # Errors |
729 | | /// |
730 | | /// Returns an error if any step fails. |
731 | 11 | pub fn prepare_release<S: ReleaseSystem>(system: &S) -> Result<()> { |
732 | 11 | let status = system.git_status_porcelain()?0 ; |
733 | 11 | if !status.trim().is_empty() { |
734 | 1 | bail!("git working directory is not clean - commit or stash changes first:\n{status}"); |
735 | 10 | } |
736 | | |
737 | 10 | let current_branch = system.git_current_branch()?0 ; |
738 | 10 | let cargo_toml = system.read_cargo_toml()?0 ; |
739 | 10 | let current_version: Version = crate::changelog::extract_version_from_cargo_toml(&cargo_toml)?0 |
740 | 10 | .parse() |
741 | 10 | .context("failed to parse current version as semver")?0 ; |
742 | | |
743 | 10 | log::info!("Current branch: {current_branch}"); |
744 | 10 | log::info!("Current version: {current_version}"); |
745 | | |
746 | 9 | let (suggested_type, suggested_version) = |
747 | 10 | suggest_next_version(¤t_version, ¤t_branch)?1 ; |
748 | | |
749 | 9 | let prompt = format!( |
750 | | "Preparing {suggested_type} release: {current_version} -> {suggested_version}. Continue? [Y/n]: " |
751 | | ); |
752 | 9 | let answer = system.prompt_user(&prompt)?0 ; |
753 | | |
754 | 9 | let (next_version, actual_type) = |
755 | 9 | if answer.eq_ignore_ascii_case("n") || answer8 .eq_ignore_ascii_case("no") { |
756 | 1 | let custom_str = system.prompt_user(&format!( |
757 | 1 | "Enter custom version (current: {current_version}): " |
758 | 1 | ))?0 ; |
759 | 1 | if custom_str.is_empty() { |
760 | 0 | bail!("version cannot be empty"); |
761 | 1 | } |
762 | 1 | let custom: Version = custom_str |
763 | 1 | .parse() |
764 | 1 | .context("invalid version format - use semantic versioning (e.g. 1.2.3)")?0 ; |
765 | 1 | let release_type = determine_release_type(¤t_version, &custom); |
766 | 1 | (custom, release_type) |
767 | 8 | } else if answer.is_empty() |
768 | 8 | || answer.eq_ignore_ascii_case("y") |
769 | 0 | || answer.eq_ignore_ascii_case("yes") |
770 | | { |
771 | 8 | (suggested_version, suggested_type) |
772 | | } else { |
773 | 0 | bail!("invalid input - please enter Y or n"); |
774 | | }; |
775 | | |
776 | 9 | let releases_from_main = current_branch == "main"; |
777 | 9 | let opens_pr = |
778 | 9 | releases_from_main && matches!1 (actual_type8 , ReleaseType::Major | ReleaseType::Minor); |
779 | 9 | let maintenance_branch = if releases_from_main { |
780 | 8 | format!("{}.{}-maintenance", next_version.major, next_version.minor) |
781 | | } else { |
782 | 1 | current_branch.clone() |
783 | | }; |
784 | 9 | let pr_branch = opens_pr.then(|| format!7 ("release-{next_version}")); |
785 | | |
786 | 9 | log::info!("Preparing {actual_type} release: {current_version} -> {next_version}"); |
787 | 9 | log::info!("Maintenance branch: {maintenance_branch}"); |
788 | | |
789 | 9 | if releases_from_main { |
790 | 8 | ensure_maintenance_branch_ready(system, &maintenance_branch)?3 ; |
791 | 1 | } |
792 | | |
793 | 6 | if let Some(pr_branch_name4 ) = pr_branch.as_deref() { |
794 | 4 | log::info!("Creating release branch: {pr_branch_name}"); |
795 | 4 | system.git_checkout_new_branch(pr_branch_name)?0 ; |
796 | 2 | } |
797 | | |
798 | 6 | log::info!("Updating Cargo.toml version to {next_version}"); |
799 | 6 | let updated_cargo = set_cargo_toml_version(&cargo_toml, &next_version.to_string())?0 ; |
800 | 6 | system.write_cargo_toml(&updated_cargo)?0 ; |
801 | | |
802 | 6 | log::info!("Updating Cargo.lock"); |
803 | 6 | system.cargo_update_workspace()?0 ; |
804 | | |
805 | 6 | log::info!("Generating changelog"); |
806 | 6 | system.generate_changelog()?0 ; |
807 | | |
808 | 6 | let commit_message = format!("Version {next_version}"); |
809 | 6 | log::info!("Committing: {commit_message}"); |
810 | 6 | system.git_add(&[ |
811 | 6 | "Cargo.toml".to_owned(), |
812 | 6 | "Cargo.lock".to_owned(), |
813 | 6 | "CHANGELOG.md".to_owned(), |
814 | 6 | "changelogging.toml".to_owned(), |
815 | 6 | ])?0 ; |
816 | | // Skip pre-commit hooks: the project's hook runs `cargo build --workspace |
817 | | // --all-targets`, which would try to replace the running xtask.exe and |
818 | | // fail on Windows with an access-denied error. |
819 | 6 | system.git_commit(&commit_message, true)?0 ; |
820 | | |
821 | 6 | if let Some(pr_branch_name4 ) = pr_branch.as_deref() { |
822 | 4 | log::info!("Pushing release branch: {pr_branch_name}"); |
823 | 4 | system.git_push(&[ |
824 | 4 | "-u".to_owned(), |
825 | 4 | "origin".to_owned(), |
826 | 4 | pr_branch_name.to_owned(), |
827 | 4 | ])?0 ; |
828 | | |
829 | 4 | log::info!("Opening PR against {maintenance_branch}"); |
830 | 4 | system.gh_pr_create(&maintenance_branch)?0 ; |
831 | | |
832 | 4 | log::info!( |
833 | | "Release {next_version} prepared on branch {pr_branch_name} \ |
834 | | with PR against {maintenance_branch}" |
835 | | ); |
836 | 4 | log::info!( |
837 | | "After the PR is merged, switch to {maintenance_branch}, \ |
838 | | pull, and run `cargo xtask create-release-tag` to tag the release" |
839 | | ); |
840 | | } else { |
841 | 2 | log::info!("Pushing to remote"); |
842 | 2 | system.git_push(&[])?0 ; |
843 | | |
844 | 2 | log::info!("Release {next_version} prepared on branch {maintenance_branch}"); |
845 | 2 | log::info!("Run `cargo xtask create-release-tag` to tag the release"); |
846 | | } |
847 | 6 | Ok(()) |
848 | 11 | } |
849 | | |
850 | | /// Create and push an annotated git tag for the current release version. |
851 | | /// |
852 | | /// Full workflow: |
853 | | /// 1. Verify on a maintenance branch. |
854 | | /// 2. Read version from `Cargo.toml`. |
855 | | /// 3. Check the tag does not already exist. |
856 | | /// 4. Verify the latest commit message is `"Version X.Y.Z"`. |
857 | | /// 5. Fetch from remote and check not behind. |
858 | | /// 6. Prompt user for confirmation. |
859 | | /// 7. Create annotated tag and push. |
860 | | /// |
861 | | /// # Arguments |
862 | | /// |
863 | | /// * `system` - Injected I/O provider. |
864 | | /// |
865 | | /// # Errors |
866 | | /// |
867 | | /// Returns an error if any validation step fails. |
868 | 6 | pub fn create_release_tag<S: ReleaseSystem>(system: &S) -> Result<()> { |
869 | 6 | let current_branch = system.git_current_branch()?0 ; |
870 | 6 | if !current_branch.ends_with("-maintenance") { |
871 | 1 | bail!( |
872 | | "must be on a maintenance branch to create a release tag \ |
873 | | (current branch: {current_branch}) - run `cargo xtask prepare-release` first" |
874 | | ); |
875 | 5 | } |
876 | | |
877 | 5 | let cargo_toml = system.read_cargo_toml()?0 ; |
878 | 5 | let version_str = crate::changelog::extract_version_from_cargo_toml(&cargo_toml)?0 ; |
879 | 5 | let version: Version = version_str |
880 | 5 | .parse() |
881 | 5 | .context("failed to parse version as semver")?0 ; |
882 | | |
883 | 5 | log::info!("Current branch: {current_branch}"); |
884 | 5 | log::info!("Version to tag: {version}"); |
885 | | |
886 | 5 | let existing_tag = system.git_tag_list(&version.to_string())?0 ; |
887 | 5 | if !existing_tag.trim().is_empty() { |
888 | 1 | bail!("tag {version} already exists"); |
889 | 4 | } |
890 | | |
891 | 4 | let commit_msg = system.git_log_latest_subject()?0 ; |
892 | 4 | let expected_msg = format!("Version {version}"); |
893 | 4 | if commit_msg != expected_msg { |
894 | 1 | bail!( |
895 | | "latest commit message does not match expected version commit\n\ |
896 | | expected: {expected_msg}\n\ |
897 | | actual: {commit_msg}\n\ |
898 | | run `cargo xtask prepare-release` first" |
899 | | ); |
900 | 3 | } |
901 | | |
902 | 3 | log::info!("Fetching latest changes from remote"); |
903 | 3 | if let Err(e0 ) = system.git_fetch() { |
904 | 0 | log::warn!("Failed to fetch from remote, continuing anyway: {e}"); |
905 | 3 | } |
906 | | |
907 | 3 | let behind = system.git_rev_list_count_behind(¤t_branch)?0 ; |
908 | 3 | if behind > 0 { |
909 | 1 | bail!("local branch is {behind} commit(s) behind remote - run `git pull` first"); |
910 | 2 | } |
911 | | |
912 | 2 | let answer = system.prompt_user(&format!( |
913 | 2 | "About to create and push tag '{version}'. Continue? [Y/n]: " |
914 | 2 | ))?0 ; |
915 | 2 | if answer.eq_ignore_ascii_case("n") || answer1 .eq_ignore_ascii_case("no") { |
916 | 1 | log::info!("Tag creation cancelled"); |
917 | 1 | return Ok(()); |
918 | 1 | } |
919 | | |
920 | 1 | let tag_message = format!("Version {version}"); |
921 | 1 | log::info!("Creating annotated tag: {version}"); |
922 | 1 | system.git_create_annotated_tag(&version.to_string(), &tag_message)?0 ; |
923 | | |
924 | 1 | log::info!("Pushing tag to remote"); |
925 | 1 | system.git_push_tag(&version.to_string())?0 ; |
926 | | |
927 | 1 | log::info!("Tag '{version}' created and pushed"); |
928 | 1 | log::info!("Check: https://github.com/whmade/cssh-rs/actions/workflows/release.yml"); |
929 | 1 | Ok(()) |
930 | 6 | } |
931 | | |
932 | | #[cfg(test)] |
933 | | #[path = "tests/test_release.rs"] |
934 | | mod tests; |